package pl.radical.open.gg;

import pl.radical.open.gg.event.ConnectionListener;
import pl.radical.open.gg.event.GGPacketListener;
import pl.radical.open.gg.event.PingListener;
import pl.radical.open.gg.packet.GGHeader;
import pl.radical.open.gg.packet.GGIncomingPackage;
import pl.radical.open.gg.packet.GGOutgoingPackage;
import pl.radical.open.gg.packet.dicts.SessionState;
import pl.radical.open.gg.packet.handlers.PacketChain;
import pl.radical.open.gg.packet.handlers.PacketContext;
import pl.radical.open.gg.packet.out.GGPing;
import pl.radical.open.gg.utils.GGUtils;

import java.io.BufferedInputStream;
import java.io.BufferedOutputStream;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.net.HttpURLConnection;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.net.Socket;
import java.net.SocketAddress;
import java.net.URL;
import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import javax.swing.event.EventListenerList;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
 * The default implementation of <code>IConnectionService</code>.
 * <p>
 * Created on 2004-11-27
 * 
 * @author <a href="mailto:mati@sz.home.pl">Mateusz Szczap</a>
 */
public class DefaultConnectionService implements IConnectionService {
	private static final Logger LOG = LoggerFactory.getLogger(DefaultConnectionService.class);

	private static final String WINDOWS_ENCODING = "windows-1250";

	private final EventListenerList eventListenerList = new EventListenerList();

	/** reference to session object */
	private Session session = null;

	private final ConcurrentLinkedQueue<GGOutgoingPackage> senderQueue = new ConcurrentLinkedQueue<GGOutgoingPackage>();

	/** chain that handles packages */
	private PacketChain packetChain = null;

	/** thread that monitors connection */
	private ConnectionThread connectionThread = null;

	/** the thread that pings the connection to keep it alive */
	private PingerThread connectionPinger = null;

	private IServer server = null;

	// friendly
	DefaultConnectionService(final Session session) throws GGException {
		if (session == null) {
			throw new IllegalArgumentException("session cannot be null");
		}
		this.session = session;
		packetChain = new PacketChain();
	}

	/**
	 * @see pl.radical.open.gg.IConnectionService#getServer(int) Example return:
	 * 
	 *      <pre>
	 * 0 0 91.197.13.78:8074 91.197.13.78
	 * </pre>
	 */
	public IServer[] lookupServer(final int uin) throws GGException {
		if (LOG.isTraceEnabled()) {
			LOG.trace("lookupServer() executed for user [" + uin + "]");
		}
		try {
			final IGGConfiguration configuration = session.getGGConfiguration();

			final URL url = new URL(configuration.getServerLookupURL() + "?fmnumber=" + Integer.valueOf(uin) + "&version=8.0.0.7669");
			if (LOG.isDebugEnabled()) {
				LOG.debug("GG HUB URL address: {}", url);
			}

			final HttpURLConnection con = (HttpURLConnection) url.openConnection();
			con.setConnectTimeout(configuration.getSocketTimeoutInMiliseconds());
			con.setReadTimeout(configuration.getSocketTimeoutInMiliseconds());

			con.setDoInput(true);
			con.connect();
			final BufferedReader reader = new BufferedReader(new InputStreamReader(con.getInputStream(), WINDOWS_ENCODING));

			final String line = reader.readLine();
			reader.close();

			if (LOG.isDebugEnabled()) {
				LOG.debug("Dane zwrócone przez serwer: {}", line);
			}

			if (line != null && line.length() > 22) {
				return parseAddress(line);
			} else {
				throw new GGException("GG HUB didn't provided a valid IP address of GG server, aborting");
			}
		} catch (final IOException ex) {
			throw new GGException("Unable to get default server for uin: " + uin, ex);
		}
	}

	/**
	 * @see pl.radical.open.gg.IConnectionService#connect()
	 */
	// TODO Add HTTPS support as a fallback
	public void connect(final IServer[] server) throws GGException { // NOPMD by LRzanek on 04.05.10 02:28
		if (server == null) {
			throw new GGException("Server cannot be null");
		}
		this.server = server[0];
		checkConnectionState();
		session.getSessionAccessor().setSessionState(SessionState.CONNECTING);
		try {
			connectionThread = new ConnectionThread();
			connectionPinger = new PingerThread();
			connectionThread.openConnection(server[0].getAddress(), server[0].getPort());
			connectionPinger.startPinging();
		} catch (final IOException ex) {
			session.getSessionAccessor().setSessionState(SessionState.CONNECTION_ERROR);
			throw new GGException("Unable to connect to Gadu-Gadu server: " + server[0], ex);
		}
	}

	/**
	 * @see pl.radical.open.gg.IConnectionService#disconnect()
	 */
	public void disconnect() throws GGException {
		checkDisconnectionState();
		session.getSessionAccessor().setSessionState(SessionState.DISCONNECTING);
		try {
			if (connectionPinger != null) {
				connectionPinger.stopPinging();
				connectionPinger = null;
			}
			if (connectionThread != null) {
				connectionThread.closeConnection();
				connectionThread = null;
			}
			server = null;
			session.getSessionAccessor().setSessionState(SessionState.DISCONNECTED);
			notifyConnectionClosed();
		} catch (final IOException ex) {
			LOG.error("IOException occured while trying to disconnect", ex);
			session.getSessionAccessor().setSessionState(SessionState.CONNECTION_ERROR);
			throw new GGException("Unable to close connection to server", ex);
		}
	}

	private void checkDisconnectionState() throws GGSessionException {
		if (session.getSessionState() == SessionState.DISCONNECTED) {
			throw new GGSessionException(SessionState.DISCONNECTED);
		}
	}

	/**
	 * @see pl.radical.open.gg.IConnectionService#isConnected()
	 */
	public boolean isConnected() {
		final boolean authenticated = session.getSessionState() == SessionState.LOGGED_IN;
		final boolean authenticationAwaiting = session.getSessionState() == SessionState.AUTHENTICATION_AWAITING;
		final boolean connected = session.getSessionState() == SessionState.CONNECTED;

		return authenticated || authenticationAwaiting || connected;
	}

	/**
	 * @see pl.radical.open.gg.IConnectionService#getServer()
	 */
	public IServer getServer() {
		return server;
	}

	/**
	 * @see pl.radical.open.gg.IConnectionService#addConnectionListener(pl.radical.open.gg.event.ConnectionListener)
	 */
	public void addConnectionListener(final ConnectionListener connListener) {
		if (connListener == null) {
			throw new IllegalArgumentException("connectionListener cannot be null");
		}
		eventListenerList.add(ConnectionListener.class, connListener);
	}

	/**
	 * @see pl.radical.open.gg.IConnectionService#removeConnectionListener(pl.radical.open.gg.event.ConnectionListener)
	 */
	public void removeConnectionListener(final ConnectionListener connListener) {
		if (connListener == null) {
			throw new IllegalArgumentException("connectionListener cannot be null");
		}
		eventListenerList.remove(ConnectionListener.class, connListener);
	}

	/**
	 * @see pl.radical.open.gg.IConnectionService#addPacketListener(pl.radical.open.gg.event.GGPacketListener)
	 */
	public void addPacketListener(final GGPacketListener packetListener) {
		if (packetListener == null) {
			throw new IllegalArgumentException("packetListener cannot be null");
		}
		eventListenerList.add(GGPacketListener.class, packetListener);
	}

	/**
	 * @see pl.radical.open.gg.IConnectionService#removePacketListener(pl.radical.open.gg.event.GGPacketListener)
	 */
	public void removePacketListener(final GGPacketListener packetListener) {
		if (packetListener == null) {
			throw new IllegalArgumentException("packetListener cannot be null");
		}
		eventListenerList.remove(GGPacketListener.class, packetListener);
	}

	/**
	 * @see pl.radical.open.gg.IConnectionService#addPingListener(pl.radical.open.gg.event.PingListener)
	 */
	public void addPingListener(final PingListener pingListener) {
		if (pingListener == null) {
			throw new IllegalArgumentException("pingListener cannot be null");
		}
		eventListenerList.add(PingListener.class, pingListener);
	}

	/**
	 * @see pl.radical.open.gg.IConnectionService#removePingListener(pl.radical.open.gg.event.PingListener)
	 */
	public void removePingListener(final PingListener pingListener) {
		if (pingListener == null) {
			throw new IllegalArgumentException("pingListener cannot be null");
		}
		eventListenerList.remove(PingListener.class, pingListener);
	}

	protected void notifyConnectionEstablished() throws GGException {
		session.getSessionAccessor().setSessionState(SessionState.AUTHENTICATION_AWAITING);
		final ConnectionListener[] connListeners = eventListenerList.getListeners(ConnectionListener.class);
		for (final ConnectionListener connectionListener : connListeners) {
			connectionListener.connectionEstablished();
		}
		// this could be also realized as a ConnectionHandler in session class
	}

	protected void notifyConnectionClosed() throws GGException {
		session.getSessionAccessor().setSessionState(SessionState.DISCONNECTED);
		final ConnectionListener[] connListeners = eventListenerList.getListeners(ConnectionListener.class);
		for (final ConnectionListener connectionListener : connListeners) {
			connectionListener.connectionClosed();
		}
	}

	protected void notifyConnectionError(final Exception ex) throws GGException {
		final ConnectionListener[] connListeners = eventListenerList.getListeners(ConnectionListener.class);
		for (final ConnectionListener connectionListener : connListeners) {
			connectionListener.connectionError(ex);
		}
		session.getSessionAccessor().setSessionState(SessionState.CONNECTION_ERROR);
	}

	protected void notifyPingSent() {
		final PingListener[] pingListeners = eventListenerList.getListeners(PingListener.class);
		for (final PingListener pingListener : pingListeners) {
			pingListener.pingSent(server);
		}
	}

	protected void notifyPongReceived() {
		final PingListener[] pingListeners = eventListenerList.getListeners(PingListener.class);
		for (final PingListener pingListener : pingListeners) {
			pingListener.pongReceived(server);
		}
	}

	protected void notifyPacketReceived(final GGIncomingPackage incomingPackage) {
		final GGPacketListener[] packetListeners = eventListenerList.getListeners(GGPacketListener.class);
		for (final GGPacketListener packetListener : packetListeners) {
			packetListener.receivedPacket(incomingPackage);
		}
	}

	protected void notifyPacketSent(final GGOutgoingPackage outgoingPackage) {
		final GGPacketListener[] packetListeners = eventListenerList.getListeners(GGPacketListener.class);
		for (final GGPacketListener packetListener : packetListeners) {
			packetListener.sentPacket(outgoingPackage);
		}
	}

	protected void sendPackage(final GGOutgoingPackage outgoingPackage) throws IOException {
		senderQueue.add(outgoingPackage);
	}

	private void checkConnectionState() throws GGSessionException {
		switch (session.getSessionState()) {
			case CONNECTION_AWAITING:
			case DISCONNECTED:
			case CONNECTION_ERROR:
				break;
			default:
				throw new GGSessionException(session.getSessionState());
		}
	}

	/**
	 * Parses the server's address.
	 * 
	 * @param line
	 *            line to be parsed.
	 * @return <code>Server</code> the server object.
	 */
	private static Server[] parseAddress(final String line) {
		if (LOG.isTraceEnabled()) {
			LOG.trace("Parsing token information from hub: [" + line + "]");
		}
		final Pattern p = Pattern.compile("\\d\\s\\d\\s((?:\\d{1,3}\\.?+){4})\\:(\\d{2,4})\\s((?:\\d{1,3}\\.?+){4})");
		final Matcher m = p.matcher(line);

		if (!m.matches()) {
			throw new IllegalArgumentException("String returned by GG HUB is not what was expected");
		} else {
			final Server[] servers = new Server[2];

			if (LOG.isTraceEnabled()) {
				LOG.trace("Znaleziono prawidłowy string w danych przesłanych przez GG HUB:");
				for (int i = 1; i <= m.groupCount(); i++) {
					LOG.trace("--->  znaleziona grupa w adresie [{}]: {}", i, m.group(i));
				}
			}

			servers[0] = new Server(m.group(1), Integer.parseInt(m.group(2)));
			servers[1] = new Server(m.group(3), 443);
			return servers;
		}
	}

	private class ConnectionThread extends Thread {

		private static final int HEADER_LENGTH = 8;

		private Socket socket = null;
		private BufferedInputStream dataInput = null;
		private BufferedOutputStream dataOutput = null;
		private boolean active = true;

		@Override
		public void run() {
			try {
				while (active) {
					handleInput();
					if (!senderQueue.isEmpty()) {
						handleOutput();
					}
					final int sleepTime = session.getGGConfiguration().getConnectionThreadSleepTimeInMiliseconds();
					Thread.sleep(sleepTime);
				}
				dataInput = null;
				dataOutput = null;
				socket.close();
			} catch (final Exception ex) { // FIXME Czy ten catch jest potrzebny??
				try {
					active = false;
					notifyConnectionError(ex);
				} catch (final GGException ex2) {
					LOG.warn("Unable to notify listeners", ex);
				}
			}
		}

		private void handleInput() throws IOException, GGException {
			final byte[] headerData = new byte[HEADER_LENGTH];
			if (dataInput.available() > 0) {
				dataInput.read(headerData);
				decodePacket(new GGHeader(headerData));
			}
		}

		private void handleOutput() throws IOException {
			while (!senderQueue.isEmpty() && !socket.isClosed() && dataOutput != null) {
				final GGOutgoingPackage outgoingPackage = senderQueue.poll();
				sendPackage(outgoingPackage);
				notifyPacketSent(outgoingPackage);
			}
		}

		private boolean isActive() {
			return active;
		}

		private void openConnection(final String host, final int port) throws IOException {
			// add runtime checking for port
			socket = new Socket();
			final SocketAddress socketAddress = new InetSocketAddress(InetAddress.getByName(host), port);
			final int socketTimeoutInMiliseconds = session.getGGConfiguration().getSocketTimeoutInMiliseconds();
			socket.connect(socketAddress, socketTimeoutInMiliseconds);
			socket.setKeepAlive(true);
			socket.setSoTimeout(socketTimeoutInMiliseconds);
			dataInput = new BufferedInputStream(socket.getInputStream());
			dataOutput = new BufferedOutputStream(socket.getOutputStream());
			start();
		}

		private void closeConnection() throws IOException {
			if (LOG.isDebugEnabled()) {
				LOG.debug("Closing connection...");
			}
			active = false;
		}

		private synchronized void sendPackage(final GGOutgoingPackage op) throws IOException {
			if (LOG.isDebugEnabled()) {
				LOG.debug("Sending packet: {}, packetPayLoad: {}", op.getPacketType(), GGUtils.prettyBytesToString(op.getContents()));
			}

			dataOutput.write(GGUtils.intToByte(op.getPacketType()));
			dataOutput.write(GGUtils.intToByte(op.getContents().length));

			if (op.getContents().length > 0) {
				dataOutput.write(op.getContents());
			}

			dataOutput.flush();
		}

		private void decodePacket(final GGHeader header) throws IOException, GGException {
			final byte[] keyBytes = new byte[header.getLength()];
			dataInput.read(keyBytes);
			final PacketContext context = new PacketContext(session, header, keyBytes);
			packetChain.sendToChain(context);
		}

	}

	private class PingerThread extends Thread {

		private boolean active = false;

		/**
		 * @see java.lang.Thread#run()
		 */
		@Override
		public void run() {
			while (active && connectionThread.isActive()) {
				try {
					if (LOG.isDebugEnabled()) {
						LOG.debug("Pinging...");
					}
					sendPackage(GGPing.getPing());
					notifyPingSent();
					final int pingInterval = session.getGGConfiguration().getPingIntervalInMiliseconds();
					Thread.sleep(pingInterval);
				} catch (final IOException ex) {
					active = false;
					// LOG.error("PingerThreadError: ", ex);
					try {
						notifyConnectionError(ex);
					} catch (final GGException e) {
						LOG.warn("Unable to notify connection error listeners", ex);
					}
				} catch (final InterruptedException ex) {
					active = false;
					if (LOG.isDebugEnabled()) {
						LOG.debug("PingerThread was interruped", ex);
					}
				}
			}
		}

		private void startPinging() {
			if (LOG.isDebugEnabled()) {
				LOG.debug("Starting pinging...");
			}
			active = true;
			start();
		}

		private void stopPinging() {
			if (LOG.isDebugEnabled()) {
				LOG.debug("Stopping pinging...");
			}
			active = false;
		}
	}

}
